from __future__ import annotations

import frappe
from frappe import _
from frappe.utils import now_datetime, getdate
from datetime import datetime, timedelta
from typing import Optional, Dict, Any


# Storage helpers (position volume maps)
 

# ----------------------- Account Item hard-coded service codes -----------------------
# 这些 code 应与 `Account Item.item_code` 保持一致，用于各业务模块统一引用
ACCT_ITEM_CODE = {
    "STORAGE": "STORAGE",                           # 存储
    "INBOUND": "INBOUND",                           # 入库
    "LOGISTICS": "LOGISTICS",                       # 物流/运配
    "B2C_OUTBOUND_PROD": "B2C_OUTBOUND_PROD",       # B2C 出库生产（拣选/包装等）
    "B2C_RETURN": "B2C_RETURN",                     # B2C 销退
    "B2B_OUTBOUND": "B2B_OUTBOUND",                 # B2B 出库
}

def _ensure_open_cycle(cycle_name: str):
    cycle = frappe.get_doc("Account Billing Cycle Inst", cycle_name)
    if str(cycle.status) not in ("Draft", "Open"):
        frappe.throw(_(f"Billing cycle {cycle_name} is not open for changes (status={cycle.status})"))
    return cycle


@frappe.whitelist()
def create_billing_cycle(period_start: str, period_end: str, company: Optional[str] = None, currency: Optional[str] = None) -> Dict[str, Any]:
    if not period_start or not period_end:
        return {"success": False, "error": "period_start, period_end are required"}

    doc = frappe.get_doc({
        "doctype": "Account Billing Cycle Inst",
        "company": company,
        "currency": currency,
        "period_start": getdate(period_start),
        "period_end": getdate(period_end),
        "status": "Open",
        "generated_on": now_datetime(),
    })
    doc.insert(ignore_permissions=True)
    return {"success": True, "name": doc.name}


@frappe.whitelist()
def add_billing_detail(
    billing_cycle: str,
    account_item: str,
    unit1_qty: float = 0.0,
    unit2_qty: float = 0.0,
    unit_price1: Optional[float] = None,
    unit_price2: Optional[float] = None,
    currency: Optional[str] = None,
    event_time: Optional[str] = None,
    source_type: Optional[str] = None,
    source_id: Optional[str] = None,
) -> Dict[str, Any]:
    """Create one billing detail.
    Currency MUST come from Pricing Plan for the given account_item (nearest to fee ownership).
    If unit_price1/2 are missing, they will be resolved from Pricing Plan as well.
    """
    if not billing_cycle or not account_item:
        return {"success": False, "error": "billing_cycle, account_item required"}

    cycle = _ensure_open_cycle(billing_cycle)

    # ALWAYS resolve currency from plan; prices resolved if missing
    p1_resolved, p2_resolved, plan_currency = _get_plan_prices(account_item, cycle, None)
    if not plan_currency:
        frappe.throw(_(f"Pricing Plan currency not configured for item {account_item}"))
    if unit_price1 is None:
        unit_price1 = p1_resolved or 0
    if unit_price2 is None and p2_resolved is not None:
        unit_price2 = p2_resolved

    unit1_qty = float(unit1_qty or 0)
    unit2_qty = float(unit2_qty or 0)
    unit_price1 = float(unit_price1 or 0)
    unit_price2 = float(unit_price2 or 0)

    amount = round(unit1_qty * unit_price1 + unit2_qty * unit_price2, 4)

    detail = frappe.get_doc({
        "doctype": "Account Billing Detail Inst",
        "billing_cycle": billing_cycle,
        "customer": None,
        "company": getattr(cycle, "company", None),
        "account_item": account_item,
        "event_time": event_time or now_datetime(),
        "source_type": source_type,
        "source_id": source_id,
        "unit1_qty": unit1_qty,
        "unit2_qty": unit2_qty,
        "unit_price1": unit_price1,
        "unit_price2": unit_price2,
        "amount": amount,
        "currency": plan_currency,
    })
    detail.insert(ignore_permissions=True)
    return {"success": True, "name": detail.name}


@frappe.whitelist()
def finalize_billing_cycle(billing_cycle: str) -> Dict[str, Any]:
    """Freeze details and compute totals; set status=ReadyForReview."""
    cycle = _ensure_open_cycle(billing_cycle)

    rows = frappe.get_all(
        "Account Billing Detail Inst",
        filters={"billing_cycle": billing_cycle},
        fields=["amount"],
        limit_page_length=0,
    )
    total_gross = sum(float(r.get("amount") or 0) for r in rows)

    cycle.db_set("total_gross", total_gross, update_modified=False)
    cycle.db_set("status", "ReadyForReview", update_modified=False)
    # per-item summary snapshot
    try:
        frappe.db.sql("delete from `tabAccount Billing Summary Inst` where billing_cycle=%s", billing_cycle)
        agg = frappe.db.sql(
            """
            select account_item,
                   sum(coalesce(unit1_qty,0)) as u1,
                   sum(coalesce(unit2_qty,0)) as u2,
                   sum(coalesce(amount,0)) as amt,
                   max(currency) as curr
            from `tabAccount Billing Detail Inst`
            where billing_cycle=%s
            group by account_item
            """,
            (billing_cycle,), as_dict=True,
        )
        for r in agg:
            # fetch pricing plan price1/2 for this item
            p1, p2, _cur = _get_plan_prices(r.get("account_item"), cycle, r.get("curr"))
            doc = frappe.get_doc({
                "doctype": "Account Billing Summary Inst",
                "billing_cycle": billing_cycle,
                "company": getattr(cycle, "company", None),
                "account_item": r.get("account_item"),
                "plan_unit_price1": float(p1 or 0),
                "plan_unit_price2": (float(p2) if p2 is not None else None),
                "total_unit1_qty": float(r.get("u1") or 0),
                "total_unit2_qty": float(r.get("u2") or 0),
                "total_amount": float(r.get("amt") or 0),
                "currency": r.get("curr"),
            })
            doc.insert(ignore_permissions=True)
    except Exception:
        frappe.logger().warning("[Billing] build summary snapshot failed: %s" % frappe.get_traceback())

    cycle.reload()
    return {
        "success": True,
        "totals": {"gross": total_gross},
        "status": cycle.status,
    }


# ----------------------- Billing policy helpers -----------------------

def _get_active_policy(company: str):
    pol_name = frappe.db.get_value(
        "Account Billing Policy",
        {"company": company, "is_active": 1},
        "name",
    )
    return frappe.get_doc("Account Billing Policy", pol_name) if pol_name else None


def _weekday_index(weekday_label: str) -> int:
    mapping = {"Mon": 0, "Tue": 1, "Wed": 2, "Thu": 3, "Fri": 4, "Sat": 5, "Sun": 6}
    return mapping.get((weekday_label or "Mon").title(), 0)


def _floor_to_week_start(dt: datetime, week_start_label: str) -> datetime:
    idx = _weekday_index(week_start_label)
    delta = (dt.weekday() - idx) % 7
    return datetime(dt.year, dt.month, dt.day) - timedelta(days=delta)


def _period_from_policy(policy, event_dt: datetime) -> tuple[datetime, datetime, str]:
    """Return (period_start, period_end, code_text) using policy.cycle_type/code_pattern.
    period_end is inclusive end (end of day)."""
    ct = getattr(policy, "cycle_type", "Month")
    code_prefix = (getattr(policy, "code_prefix", "") or "").strip()
    pattern = getattr(policy, "code_pattern", None) or ("Month(yyyyMM)" if ct == "Month" else None)

    day_start = datetime(event_dt.year, event_dt.month, event_dt.day)

    if ct == "Day":
        ps, pe = day_start, day_start
        body = ps.strftime("%Y%m%d") if "yyyyMMdd" in (pattern or "") else ps.strftime("%Y%m%d")
    elif ct == "Week":
        ws = _floor_to_week_start(event_dt, getattr(policy, "week_start", "Mon"))
        ps, pe = ws, ws + timedelta(days=6)
        if pattern and "yyyy'W'WW" in pattern:
            iso_year, iso_week, _ = ps.isocalendar()
            body = f"{iso_year}W{iso_week:02d}"
        else:
            body = f"{ps.strftime('%Y%m%d')}-{pe.strftime('%Y%m%d')}"
    elif ct == "BiWeek":
        ws = _floor_to_week_start(event_dt, getattr(policy, "week_start", "Mon"))
        # anchor biweek to even multiple of 14 days since the first week of year
        year_start = _floor_to_week_start(datetime(event_dt.year, 1, 1), getattr(policy, "week_start", "Mon"))
        diff_days = (ws - year_start).days
        biweek_index = diff_days // 14
        ps = year_start + timedelta(days=biweek_index * 14)
        pe = ps + timedelta(days=13)
        body = f"{ps.strftime('%Y%m%d')}-{pe.strftime('%Y%m%d')}"
    else:  # Month
        anchor = int(getattr(policy, "anchor_day", 1) or 1)
        anchor = max(1, min(anchor, 28))  # avoid overflow
        # month window anchored by natural month
        ps = datetime(event_dt.year, event_dt.month, 1)
        # end = last day of month
        next_month = (ps.replace(day=28) + timedelta(days=4)).replace(day=1)
        pe = next_month - timedelta(days=1)
        if pattern and "yyyyMM" in pattern:
            body = ps.strftime("%Y%m")
        else:
            body = f"{ps.strftime('%Y%m%d')}-{pe.strftime('%Y%m%d')}"

    code = f"{code_prefix + '-' if code_prefix else ''}{body}"
    return ps, pe, code


@frappe.whitelist()
def resolve_cycle_for_event(company: str, currency: Optional[str] = None, event_time: Optional[str] = None) -> Dict[str, Any]:
    """根据公司账期策略，按事件时间定位/创建账期并返回 cycle_code/name。
    使用系统本地时间；若无策略则报错。"""
    if not company:
        return {"success": False, "error": "company is required"}
    policy = _get_active_policy(company)
    if not policy:
        return {"success": False, "error": "No active Account Billing Policy for company"}

    currency = currency or getattr(policy, "currency", None)

    evt_dt = getdate(event_time) if event_time else getdate(now_datetime())
    evt_dt = datetime(evt_dt.year, evt_dt.month, evt_dt.day)

    ps, pe, code = _period_from_policy(policy, evt_dt)

    # find existing cycle by company + code
    existing = frappe.db.get_value(
        "Account Billing Cycle Inst", {"company": company, "cycle_code": code}, ["name", "status"], as_dict=True
    )
    if existing:
        return {"success": True, "name": existing.get("name"), "cycle_code": code, "status": existing.get("status")}

    # create new cycle
    doc = frappe.get_doc({
        "doctype": "Account Billing Cycle Inst",
        "cycle_code": code,
        "billing_policy": policy.name,
        "company": company,
        "currency": currency,
        "period_start": getdate(ps),
        "period_end": getdate(pe),
        "status": "Open",
        "generated_on": now_datetime(),
    })
    doc.insert(ignore_permissions=True)
    return {"success": True, "name": doc.name, "cycle_code": code, "status": doc.status}


 

@frappe.whitelist()
def generate_cycles(company: str, start_date: str, periods: int) -> Dict[str, Any]:
    """按账期策略批量生成账期（人工触发）。
    - company: 公司
    - start_date: 起始日期（yyyy-mm-dd），从该日所在周期开始生成
    - periods: 连续生成的周期数（>=1）
    """
    if not company or not start_date:
        return {"success": False, "error": "company and start_date are required"}
    try:
        count = max(0, int(periods or 0))
    except Exception:
        count = 0
    if count <= 0:
        return {"success": False, "error": "periods must be positive integer"}
 
    policy = _get_active_policy(company)
    if not policy:
        return {"success": False, "error": "No active Account Billing Policy for company"}
 
    created = 0
    skipped = 0
    errors: list[str] = []
 
    # 从 start_date 所在周期开始，向后连续生成 count 个周期
    cur_dt = getdate(start_date)
    for i in range(count):
        try:
            ps, pe, code = _period_from_policy(policy, datetime(cur_dt.year, cur_dt.month, cur_dt.day))
            exists = frappe.db.exists(
                "Account Billing Cycle Inst",
                {"company": company, "cycle_code": code},
            )
            if exists:
                skipped += 1
            else:
                doc = frappe.get_doc({
                    "doctype": "Account Billing Cycle Inst",
                    "cycle_code": code,
                    "billing_policy": policy.name,
                    "company": company,
                    "currency": getattr(policy, "currency", None),
                    "period_start": getdate(ps),
                    "period_end": getdate(pe),
                    "status": "Open",
                    "generated_on": now_datetime(),
                })
                doc.insert(ignore_permissions=True)
                created += 1
            # 下一周期：从当前周期结束日的下一天开始
            cur_dt = getdate(pe + timedelta(days=1))
        except Exception as e:
            errors.append(str(e))
            # 防止死循环，尝试往后推进一天
            try:
                cur_dt = getdate(cur_dt + timedelta(days=1))
            except Exception:
                pass
 
    return {"success": True, "created": created, "skipped": skipped, "errors": errors}

@frappe.whitelist()
def get_company_balance(company: str, currency: Optional[str] = None) -> Dict[str, Any]:
    if not company:
        return {"success": False, "error": "company is required"}
    if currency:
        name = frappe.db.get_value("Acct Balance", {"company": company, "currency": currency}, "name")
        bal = frappe.db.get_value("Acct Balance", name, "balance") if name else 0
        return {"success": True, "balance": float(bal or 0), "currency": currency}
    else:
        # return all currencies
        rows = frappe.get_all("Acct Balance", filters={"company": company}, fields=["currency", "balance"], limit_page_length=0)
        return {"success": True, "balances": rows}


@frappe.whitelist()
def get_customer_balance(customer: str, company: Optional[str] = None, currency: Optional[str] = None) -> Dict[str, Any]:
    """Deprecated: kept for compatibility. Use get_company_balance."""
    if company:
        return get_company_balance(company, currency)
    return {"success": False, "error": "Use get_company_balance(company, currency)"}


@frappe.whitelist()
def accept_billing_cycle(billing_cycle: str) -> Dict[str, Any]:
    cycle = frappe.get_doc("Account Billing Cycle Inst", billing_cycle)
    if str(cycle.status) not in ("Open", "ReadyForReview"):
        return {"success": False, "error": f"cycle status invalid: {cycle.status}"}
    cycle.db_set("status", "Accepted", update_modified=False)
    return {"success": True, "status": "Accepted"}


def _resolve_price_from_plan(plan_name: str, qty: float, currency_hint: Optional[str]) -> tuple[float, Optional[str]]:
    """Simplified: pricing plan now only supports Fixed price."""
    doc = frappe.get_doc("Pricing Plan", plan_name)
    return float(getattr(doc, "fixed_price", 0) or 0), (currency_hint or getattr(doc, "currency", None))


def _resolve_price_from_item_or_plan(account_item: str, qty: float, cycle, currency_hint: Optional[str]) -> tuple[Optional[float], Optional[str]]:
    # find an active Pricing Plan for this account_item within cycle window
    filters = {"account_item": account_item}
    if getattr(cycle, "company", None):
        filters["company"] = getattr(cycle, "company")
    plans = frappe.get_all(
        "Pricing Plan",
        filters=filters,
        fields=["name", "effective_from", "effective_to", "currency", "pricing_type", "fixed_price"],
        order_by="effective_from desc",
        limit_page_length=0,
    )
    # choose first plan that covers period_end (or has no window)
    chosen = None
    pe = getattr(cycle, "period_end", None)
    ps = getattr(cycle, "period_start", None)
    for p in plans:
        ef = p.get("effective_from")
        et = p.get("effective_to")
        ok1 = (not ef) or (ps and ps >= ef) or (pe and pe >= ef)
        ok2 = (not et) or (pe and pe <= et) or (ps and ps <= et)
        if ok1 and ok2:
            chosen = p["name"]
            break
    if not chosen and plans:
        chosen = plans[0]["name"]
    if not chosen:
        return None, currency_hint
    return _resolve_price_from_plan(chosen, qty, currency_hint)


def _get_plan_prices(account_item: str, cycle, currency_hint: Optional[str]) -> tuple[Optional[float], Optional[float], Optional[str]]:
    """Fetch fixed prices (price1, price2) from active plan for the item in the cycle window.

    price1 使用 `Pricing Plan.fixed_price`（首件单价），
    price2 使用 `Pricing Plan.fixed_price2`（续件单价，可为空）。
    """
    filters = {"account_item": account_item}
    if getattr(cycle, "company", None):
        filters["company"] = getattr(cycle, "company")
    plans = frappe.get_all(
        "Pricing Plan",
        filters=filters,
        fields=["name", "effective_from", "effective_to", "currency", "fixed_price", "fixed_price2"],
        order_by="effective_from desc",
        limit_page_length=0,
    )
    chosen = None
    pe = getattr(cycle, "period_end", None)
    ps = getattr(cycle, "period_start", None)
    for p in plans:
        ef = p.get("effective_from")
        et = p.get("effective_to")
        ok1 = (not ef) or (ps and ps >= ef) or (pe and pe >= ef)
        ok2 = (not et) or (pe and pe <= et) or (ps and ps <= et)
        if ok1 and ok2:
            chosen = p
            break
    if not chosen and plans:
        chosen = plans[0]
    if not chosen:
        return None, None, currency_hint

    # 首件单价：fixed_price；续件单价：fixed_price2（可为空）
    price1 = chosen.get("fixed_price")
    price2 = chosen.get("fixed_price2")
    return (
        float(price1 or 0),
        float(price2 or 0) if price2 is not None else None,
        (currency_hint or chosen.get("currency")),
    )


 



def _payment_available_amount(payment_name: str) -> float:
    """
    计算单笔充值记录(oms_payment)当前可用于账期分配的余额。

    公式:
        可用余额 = 充值总额(amount) - 已分配金额汇总(来自 Acct Payment Allocation)

    注意:
    - 此函数只做聚合查询，不做并发锁；实际分配时应结合数据库事务，避免重复分配。
    - allocation 表记录的是已占用的金额，允许多次部分分配，累计后不得超过充值总额。
    """
    # 充值单上的原始金额
    amt = frappe.db.get_value("oms_payment", payment_name, "amount") or 0
    # 已经被分配出去的金额（所有分配流水求和，空值按0处理）
    allocated = frappe.db.sql(
        "select sum(amount) from `tabAcct Payment Allocation` where payment=%s",
        payment_name,
    )[0][0] or 0
    # 返回剩余可用额度
    return float(amt) - float(allocated)

@frappe.whitelist()
def settle_cycle_if_covered(billing_cycle: str) -> Dict[str, Any]:
    """根据账单明细总额判断账期是否已覆盖：
    - 若账单明细合计 >= 账期应收(total_gross) 且应收 > 0 → 账期状态设为 Settled。
    - 否则返回部分覆盖的状态，用于继续生成/调整明细。
    """
    cycle = frappe.get_doc("Account Billing Cycle Inst", billing_cycle)
    # 不再使用 Acct Payment Allocation Inst，直接以 Account Billing Detail Inst 中的金额合计为准
    allocated_total = frappe.db.sql(
        "select sum(amount) from `tabAccount Billing Detail Inst` where billing_cycle=%s",
        billing_cycle,
    )[0][0] or 0
    due = float(cycle.total_gross or 0)
    if allocated_total >= due and due > 0:
        cycle.db_set("status", "Settled", update_modified=False)
        try:
            # once settled, it must not be current any more
            if hasattr(cycle, "is_current"):
                cycle.db_set("is_current", 0, update_modified=False)
        except Exception:
            pass
        return {"success": True, "status": "Settled"}
    return {"success": True, "status": "Partially Covered", "allocated": allocated_total, "due": due}


 


def _collect_business_details_for_cycle(cycle) -> None:
    """Collect business records for the given cycle and insert into Account Billing Detail Inst.
    占位实现：预留扩展点。后续可通过 hooks/自定义方法按公司与账期导入出入库/面单等业务记录。
    目前不做任何操作，假设明细已被外部流程写入。
    """
    try:
        # 先执行系统内置的自动计费分发
        try:
            _run_auto_billing_services_for_cycle(cycle)
        except Exception:
            frappe.logger().warning("[Billing] auto service billing failed: %s" % frappe.get_traceback())

        # 预留扩展：hooks: upsystem_billing_collectors = ["app.module.fn"]
        for fn_path in (frappe.get_hooks().get("upsystem_billing_collectors") or []):
            try:
                fn = frappe.get_attr(fn_path)
                fn(cycle)  # 期望函数内部自行写入 Account Billing Detail Inst
            except Exception:
                frappe.logger().warning(f"[Billing] collector failed: {fn_path} -> {frappe.get_traceback()}")
    except Exception:
        pass


def _apply_discounts_for_cycle(billing_cycle: str) -> None:
    """Apply end-of-cycle discounts based on Account Discount Rule and insert negative detail.
    规则：按 priority 最小优先，命中一条（可按需扩展为叠加）。
    metric 支持 total_amount / order_count / unit1_qty / unit2_qty。
    """
    try:
        cycle = frappe.get_doc("Account Billing Cycle Inst", billing_cycle)
        company = getattr(cycle, "company", None)
        if not company:
            return
        # 聚合指标（按 account_item 维度）
        agg = frappe.db.sql(
            """
            select account_item,
                   count(*) as cnt,
                   sum(coalesce(amount,0)) as amt,
                   sum(coalesce(unit1_qty,0)) as u1,
                   sum(coalesce(unit2_qty,0)) as u2,
                   max(currency) as curr
            from `tabAccount Billing Detail Inst`
            where billing_cycle=%s
            group by account_item
            """,
            (billing_cycle,), as_dict=True,
        )
        if not agg:
            return
        # 针对每个 account_item 寻找匹配规则并应用一条优惠
        for row in agg:
            ai = row.get("account_item")
            total_amount = float(row.get("amt") or 0)
            order_count = int(row.get("cnt") or 0)
            total_u1 = float(row.get("u1") or 0)
            total_u2 = float(row.get("u2") or 0)

            rules = frappe.get_all(
                "Account Discount Rule",
                filters={"company": company, "is_active": 1, "account_item": ai},
                fields=["name", "scope", "category", "account_item", "metric", "threshold_min", "threshold_max", "discount_type", "discount_value", "cap_amount", "floor_amount", "priority", "discount_item"],
                order_by="priority asc",
                limit_page_length=0,
            )
            if not rules:
                continue
            chosen = None
            metric_value = 0.0
            for r in rules:
                m = r.get("metric") or "total_amount"
                metric_value = {
                    "total_amount": total_amount,
                    "order_count": float(order_count),
                    "unit1_qty": total_u1,
                    "unit2_qty": total_u2,
                }.get(m, total_amount)
                lo = float(r.get("threshold_min") or 0)
                hi_raw = r.get("threshold_max")
                hi_ok = True if hi_raw in (None, "",) else (metric_value <= float(hi_raw))
                if metric_value >= lo and hi_ok:
                    chosen = r
                    break
            if not chosen:
                continue

            # 计算折扣金额（基于该 account_item 的总额/数量等）
            # 说明：
            # - discount_type=percent 表示按百分比优惠，例如 10 表示打 10% 折扣（即减去总额的 10%）
            # - discount_type=amount  表示固定减免金额（与计量无关，直接减去该金额）
            # - cap_amount           表示优惠金额的上限（封顶值），若计算超过上限则取上限
            # - floor_amount         表示优惠金额的下限（保底值），若计算低于下限则取下限
            dtype = (chosen.get("discount_type") or "percent").lower()
            dval = float(chosen.get("discount_value") or 0)
            disc_amt = 0.0
            base_val = total_amount
            if dtype == "percent":
                disc_amt = round(base_val * dval / 100.0, 4)
            else:
                disc_amt = float(dval)
            cap = chosen.get("cap_amount")
            if cap not in (None, ""):
                disc_amt = min(disc_amt, float(cap))
            floor = chosen.get("floor_amount")
            if floor not in (None, ""):
                disc_amt = max(disc_amt, float(floor))
            if disc_amt <= 0:
                continue

            discount_item = chosen.get("discount_item") or "Discount"
            # 优惠不需要再按定价计划算费；优惠本身在规则中维护货币
            disc_currency = chosen.get("currency") or getattr(cycle, "currency", None)
            if not disc_currency:
                frappe.throw(_(f"Discount Rule currency not configured for item {discount_item}"))
            doc = frappe.get_doc({
                "doctype": "Account Billing Detail Inst",
                "billing_cycle": billing_cycle,
                "company": company,
                "account_item": discount_item,
                "event_time": now_datetime(),
                "unit1_qty": 0,
                "unit2_qty": 0,
                "unit_price1": 0,
                "unit_price2": 0,
                "amount": -abs(disc_amt),
                "currency": disc_currency,
                "remarks": f"Auto discount by rule {chosen.get('name')} for item {ai} metric={chosen.get('metric')} value={metric_value}"
            })
            doc.insert(ignore_permissions=True)
        # 不在此处更新账期总额；最终由 finalize_billing_cycle 按明细汇总计算
    except Exception:
        frappe.logger().warning(f"[Billing] apply discount failed: {frappe.get_traceback()}")


@frappe.whitelist()
def collect_wdt_snapshot_for_cycle(
    billing_cycle: str,
    account_item: str,
    owner_no: Optional[str] = None,
    warehouse_no: Optional[str] = None,
    is_position: Optional[int] = None,
    defect: Optional[int] = None,
    qty_field: str = "available_num",
) -> Dict[str, Any]:
    """将 WDT 库存快照转化为计费明细：
    - 选取账期内(snapshot_date ∈ [period_start, period_end]) 且 billing_included=0 的记录
    - 可选按 owner_no / warehouse_no / is_position / defect 过滤
    - 数量取自 qty_field（默认 available_num）作为 unit1_qty；unit2_qty=0
    - 单价按 Pricing Plan 解析（传入 account_item）
    - 为每条快照生成一条 Account Billing Detail Inst，并回写快照 billing_included=1、billing_cycle
    """
    if not billing_cycle or not account_item:
        return {"success": False, "error": "billing_cycle and account_item are required"}

    cycle = frappe.get_doc("Account Billing Cycle Inst", billing_cycle)
    if str(cycle.status) not in ("Draft", "Open", "ReadyForReview"):
        return {"success": False, "error": f"cycle status not allowed: {cycle.status}"}

    ps = getattr(cycle, "period_start", None)
    pe = getattr(cycle, "period_end", None)
    if not ps or not pe:
        return {"success": False, "error": "cycle period is missing"}

    # 构造过滤条件
    filters = {
        "snapshot_date": ["between", [ps, pe]],
        "billing_included": ["in", [0, None]],
    }
    if owner_no:
        filters["owner_no"] = owner_no
    if warehouse_no:
        filters["warehouse_no"] = warehouse_no
    if is_position is not None:
        filters["is_position"] = int(is_position)
    if defect is not None:
        filters["defect"] = int(defect)

    rows = frappe.get_all(
        "WDT Stock Total Snapshot Inst",
        filters=filters,
        fields=[
            "name",
            "snapshot_date",
            "owner_no",
            "warehouse_no",
            "is_position",
            "defect",
            "spec_no",
            "spec_name",
            "goods_name",
            "quantity",
            "sending_num",
            "order_num",
            "available_num",
            "batch_no",
            "product_date",
            "expire_date",
            "position_no",
            "zone_type",
        ],
        limit_page_length=0,
    )

    created = 0
    skipped = 0
    invalid = 0

    for r in rows:
        try:
            qty = float(r.get(qty_field) or 0)
        except Exception:
            qty = 0.0
        if qty <= 0:
            skipped += 1
            continue

        # 插入计费明细（单价在 add_billing_detail 内解析）
        ret = add_billing_detail(
            billing_cycle=billing_cycle,
            account_item=account_item,
            unit1_qty=qty,
            unit2_qty=0,
            event_time=r.get("snapshot_date"),
            source_type="WDT Stock",
            source_id=r.get("name"),
        )
        if not ret.get("success"):
            invalid += 1
            continue

        # 回写快照标记
        try:
            frappe.db.set_value(
                "WDT Stock Total Snapshot Inst",
                r.get("name"),
                {"billing_included": 1, "billing_cycle": billing_cycle},
                update_modified=False,
            )
        except Exception:
            pass
        created += 1

    return {"success": True, "created": created, "skipped": skipped, "invalid": invalid, "total": len(rows)}


# ----------------------- Auto-billing dispatcher by service code -----------------------
def _iter_company_pricing_items(cycle) -> list[dict]:
    company = getattr(cycle, "company", None)
    if not company:
        return []
    plans = frappe.get_all(
        "Pricing Plan",
        filters={"company": company},
        fields=["name", "account_item"],
        limit_page_length=0,
    )
    items: list[dict] = []
    seen_codes: set[str] = set()
    for p in plans:
        ai = p.get("account_item")
        if not ai:
            continue
        # 读取 item_code
        code = frappe.db.get_value("Account Item", ai, "item_code")
        if not code:
            continue
        if code in seen_codes:
            continue
        seen_codes.add(code)
        items.append({"account_item": ai, "item_code": code})
    return items


def _handle_storage_billing(cycle, account_item: str) -> dict:
    """根据快照与库位配置，按天计算存储体积并生成计费明细。
    规则：
    - 以账期 [period_start, period_end] 每日为粒度
    - 对应公司(company) 且 is_position=1 的快照，按 (warehouse_no, zone_type, position_no) 去重
    - 每个库位查 `WDT Position Config` 体积(cubic_m)；求和得到当日占用体积 daily_cubic
    - 单价取 Pricing Plan 中该 account_item 的 fixed_price（视为元/立方米/天），金额 = daily_cubic * price1
    - 为每天插入一条 `Account Billing Detail Inst`，event_time=当日
    - 将该日相关快照写回 billing_included=1, billing_cycle
    """
    company = getattr(cycle, "company", None)
    if not company:
        return {"created": 0}

    # 解析定价
    price1, _p2, currency = _get_plan_prices(account_item, cycle, None)
    price1 = float(price1 or 0)
    if not currency:
        frappe.throw(_(f"Pricing Plan currency not configured for item {account_item}"))
    if price1 <= 0:
        # 无价则不计费
        return {"created": 0}

    ps = getattr(cycle, "period_start", None)
    pe = getattr(cycle, "period_end", None)
    if not ps or not pe:
        return {"created": 0}

    # 仅在使用到存储计费时，再动态导入 WDT 辅助函数，避免模块加载期的包依赖问题
    try:
        from upsystem.upsystem.api_wdt_stock import build_position_volume_map, position_key  # type: ignore
    except Exception as e:
        frappe.throw(_(f"WDT helpers not available: {e}"))

    # 日期迭代（包含端点）
    from datetime import timedelta
    cur = ps
    created = 0
    # 缓存每个仓库的体积映射，避免重复查询
    wh_map_cache: dict[str, dict[str, float]] = {}

    while cur <= pe:
        rows = frappe.get_all(
            "WDT Stock Total Snapshot Inst",
            filters={
                "snapshot_date": cur,
                "company": company,
                "is_position": 1,
            },
            fields=["name", "warehouse_no", "zone_type", "position_no"],
            limit_page_length=0,
        )
        if rows:
            # 去重库位
            triples = {(r.get("warehouse_no"), str(r.get("zone_type")), r.get("position_no")) for r in rows if r.get("position_no")}
            daily_cubic = 0.0
            for wh, zt, pos in triples:
                if not wh or not zt or not pos:
                    continue
                if wh not in wh_map_cache:
                    wh_map_cache[wh] = build_position_volume_map(wh)
                key = position_key(wh, zt, pos)
                if key not in wh_map_cache[wh]:
                    frappe.throw(_(f"Missing position volume for {key}"))
                daily_cubic += float(wh_map_cache[wh].get(key) or 0)

            if daily_cubic > 0:
                add_billing_detail(
                    billing_cycle=cycle.name,
                    account_item=account_item,
                    unit1_qty=daily_cubic,
                    unit2_qty=0,
                    unit_price1=price1,
                    unit_price2=0,
                    currency=None,
                    event_time=cur,
                    source_type="WDT Storage Daily",
                    source_id=f"{company}|{cur}",
                )
                created += 1

                # 回写当日快照 included 标记
                frappe.db.sql(
                    """
                    update `tabWDT Stock Total Snapshot Inst`
                    set billing_included=1, billing_cycle=%s
                    where snapshot_date=%s and company=%s and is_position=1
                    """,
                    (cycle.name, cur, company),
                )

        cur = cur + timedelta(days=1)

    return {"created": created}


def _handle_inbound_billing(cycle, account_item: str) -> dict:
    """根据“供应商发货的物料申请单”落地的入库单（Stock Entry: Material Receipt）计费。
    规则：
    - 仅计算公司=cycle.company，posting_date ∈ [period_start, period_end]，docstatus=1 的入库单
    - 入库明细需存在 material_request 关联（仅按有申请的入库计费）
    - 数量为入库明细 qty 求和（件数），单价来自 Pricing Plan（元/件）
    - 每张入库单生成一条计费明细，避免重复（按 source_type+source_id 去重）
    """
    company = getattr(cycle, "company", None)
    if not company:
        return {"created": 0}

    # 解析定价（检查货币在 add_billing_detail 内完成）
    price1, _p2, _cur = _get_plan_prices(account_item, cycle, None)
    price1 = float(price1 or 0)
    if price1 <= 0:
        return {"created": 0}

    # 获取已计费的入库单（任何账期）用于排除，避免重复
    billed_receipts = set(
        frappe.get_all(
            "Account Billing Detail Inst",
            filters={"source_type": "InboundReceipt"},
            pluck="source_id",
            limit_page_length=0,
        ) or []
    )

    # 获取入库单（Material Receipt），不按时间过滤；只要未计费（不在 billed_receipts）即纳入
    receipts = frappe.get_all(
        "Stock Entry",
        filters={
            "company": company,
            "stock_entry_type": "Material Receipt",
            "docstatus": 1,
        },
        fields=["name", "posting_date"],
        limit_page_length=0,
    )

    created = 0
    for se in receipts:
        se_name = se.get("name")
        # 跳过已计费的任意账期（避免漏计/重复计费）
        if se_name in billed_receipts:
            continue

        # 仅统计与 Material Request 关联的入库明细
        details = frappe.get_all(
            "Stock Entry Detail",
            filters={"parent": se_name, "material_request": ["!=", ""]},
            fields=["qty"],
            limit_page_length=0,
        )
        total_qty = sum(float(d.get("qty") or 0) for d in details)
        if total_qty <= 0:
            continue

        add_billing_detail(
            billing_cycle=cycle.name,
            account_item=account_item,
            unit1_qty=total_qty,
            unit2_qty=0,
            unit_price1=price1,
            unit_price2=0,
            currency=None,
            event_time=se.get("posting_date"),
            source_type="InboundReceipt",
            source_id=se_name,
        )
        created += 1

    return {"created": created}


def create_inbound_billing_for_receipt(
    *,
    company: str,
    source_id: str,
    total_qty: int,
    event_time,
) -> None:
    """为单张入库单（Material Receipt）创建入库计费明细。

    说明：
    - 作为业务触发的计费入口，由入库回调等业务代码调用。
    - 统一在计费模块内完成：定位账期 → 解析 Account Item/定价 → 写入 Account Billing Detail Inst。
    - source_id 建议使用业务单号（如 Material Request / 供应商单号等），避免依赖内部 Stock Entry 名称。
    - 若后续再次跑 `_handle_inbound_billing`，会根据 source_type/source_id 去重，避免重复计费。
    """
    if not company or not source_id or not total_qty:
        return
    try:
        # 1) 根据业务交易时间按公司账期策略定位/创建账期
        cycle_res = resolve_cycle_for_event(company=company, event_time=str(event_time))
        if not cycle_res.get("success"):
            return
        billing_cycle_name = cycle_res.get("name")

        # 2) 查找当前公司的入库计费 Account Item（item_code = INBOUND）
        inbound_code = ACCT_ITEM_CODE.get("INBOUND")
        if not inbound_code:
            # 系统配置错误：缺少入库服务编码
            frappe.throw(
                _("[Billing][Inbound] ACCT_ITEM_CODE['INBOUND'] is not configured."),
                frappe.ValidationError,
            )
        account_item_name = frappe.db.get_value(
            "Account Item", {"item_code": inbound_code, "company": company}, "name"
        )
        if not account_item_name:
            # 系统配置错误：当前公司未配置入库账目
            frappe.throw(
                _(
                    "[Billing][Inbound] Inbound Account Item with item_code={0} not found for company={1}."
                ).format(inbound_code, company),
                frappe.ValidationError,
            )

        # 3) 若该 source_id 已存在入库计费明细，则不再重复生成
        exists = frappe.db.exists(
            "Account Billing Detail Inst",
            {"source_type": "InboundReceipt", "source_id": source_id},
        )
        if exists:
            return

        # 4) 检查是否配置了有效的定价策略（Pricing Plan）
        cycle_doc = frappe.get_doc("Account Billing Cycle Inst", billing_cycle_name)
        price1, _price2, plan_currency = _get_plan_prices(
            account_item=account_item_name, cycle=cycle_doc, currency_hint=None
        )
        # 无定价或无币种 → 视为未配置计费策略，直接跳过
        if plan_currency is None or price1 is None:
            return

        unit_price = float(price1 or 0)
        if unit_price <= 0:
            # 单价<=0 视为不计费
            return

        qty = float(total_qty)
        amount = round(qty * unit_price, 4)

        # 5) 写入计费明细
        detail = frappe.get_doc(
            {
                "doctype": "Account Billing Detail Inst",
                "billing_cycle": billing_cycle_name,
                "company": cycle_doc.company,
                "account_item": account_item_name,
                "event_time": str(event_time),
                "source_type": "InboundReceipt",
                "source_id": source_id,
                "unit1_qty": qty,
                "unit2_qty": 0,
                "unit_price1": unit_price,
                "unit_price2": 0,
                "amount": amount,
                "currency": plan_currency,
            }
        )
        detail.insert(ignore_permissions=True)
    except Exception:
        frappe.logger().warning(
            f"[Billing][Inbound] create_inbound_billing_for_receipt failed for source_id={source_id}: {frappe.get_traceback()}"
        )


def _handle_b2c_outbound_prod_billing(cycle, account_item: str) -> dict:
    """B2C 出库生产：
    - 数据源：Delivery Note（销售出库单），docstatus=1
    - 仅统计存在销售订单关联的出库（Delivery Note Item.against_sales_order 非空）
    - 计费：首件按 price1；续件按 price2（总件数-1，不小于0）
    - 每张出库单生成一条明细；全局去重（source_type='B2COutbound'）
    """
    company = getattr(cycle, "company", None)
    if not company:
        return {"created": 0}

    price1, price2, _cur = _get_plan_prices(account_item, cycle, None)
    price1 = float(price1 or 0)
    price2 = float(price2 or 0)
    if price1 <= 0 and price2 <= 0:
        return {"created": 0}

    billed = set(
        frappe.get_all(
            "Account Billing Detail Inst",
            filters={"source_type": "B2COutbound"},
            pluck="source_id",
            limit_page_length=0,
        ) or []
    )

    dns = frappe.get_all(
        "Delivery Note",
        filters={"company": company, "docstatus": 1},
        fields=["name", "posting_date"],
        limit_page_length=0,
    )

    created = 0
    for dn in dns:
        dn_name = dn.get("name")
        if dn_name in billed:
            continue
        items = frappe.get_all(
            "Delivery Note Item",
            filters={"parent": dn_name, "against_sales_order": ["!=", ""]},
            fields=["qty"],
            limit_page_length=0,
        )
        total_qty = sum(float(i.get("qty") or 0) for i in items)
        if total_qty <= 0:
            continue
        first_qty = 1.0 if total_qty >= 1 else 0.0
        follow_qty = max(total_qty - 1.0, 0.0)
        add_billing_detail(
            billing_cycle=cycle.name,
            account_item=account_item,
            unit1_qty=first_qty,
            unit2_qty=follow_qty,
            unit_price1=price1,
            unit_price2=price2,
            currency=None,
            event_time=dn.get("posting_date"),
            source_type="B2COutbound",
            source_id=dn_name,
        )
        created += 1

    return {"created": created}


_SERVICE_HANDLERS = {
    ACCT_ITEM_CODE.get("STORAGE"): _handle_storage_billing,
    # 入库上架：按入库单（Material Receipt）已提交的收货数量计费
    ACCT_ITEM_CODE.get("INBOUND"): _handle_inbound_billing,
    # B2C 出库生产
    ACCT_ITEM_CODE.get("B2C_OUTBOUND_PROD"): _handle_b2c_outbound_prod_billing,
    # 其他服务编码待定制化：B2C_RETURN / B2B_OUTBOUND
}


def _run_auto_billing_services_for_cycle(cycle) -> None:
    items = _iter_company_pricing_items(cycle)
    if not items:
        return
    for it in items:
        code = (it.get("item_code") or "").strip().upper()
        account_item = it.get("account_item")
        handler = _SERVICE_HANDLERS.get(code)
        if not handler:
            # 已订购但尚无自动计费实现的服务，按要求不抛错，记录并跳过
            frappe.logger().info(f"[Billing] no auto-billing handler for service code={code}; skipped")
            continue
        try:
            handler(cycle, account_item)
        except Exception:
            frappe.logger().warning(f"[Billing] handler failed for code={code}: {frappe.get_traceback()}")

@frappe.whitelist()
def run_billing_pipeline_for_cycle(billing_cycle: str, auto: int = 0) -> Dict[str, Any]:
    """手动/自动出账流水（以优惠后结果为准）：
    1) 将账期置为 ReadyForReview
    2) 采集业务明细（占位，预留 hooks/内置分发）
    3) 应用折扣（插入负行）
    4) 最终汇总生成（含 per-item summary，包含折扣行）
    5) 标记 auto_billed_at（若 auto=1）
    """
    cycle = _ensure_open_cycle(billing_cycle)
    cycle.db_set("status", "ReadyForReview", update_modified=False)
    _collect_business_details_for_cycle(cycle)
    _apply_discounts_for_cycle(billing_cycle)
    finalize_billing_cycle(billing_cycle)
    if int(auto or 0) == 1:
        try:
            cycle.db_set("auto_billed_at", now_datetime(), update_modified=False)
        except Exception:
            pass
    return {"success": True, "billing_cycle": billing_cycle}


@frappe.whitelist()
def auto_billing_for_expired_cycles() -> Dict[str, Any]:
    """每天执行：对昨天已到期仍为 Open/Draft 的账期，自动执行出账流水，并记录 auto_billed_at。"""
    today = getdate(now_datetime())
    target = frappe.get_all(
        "Account Billing Cycle Inst",
        filters={
            "period_end": ["<", today],
            "status": ["in", ["Open", "Draft"]],
        },
        pluck="name",
        limit_page_length=0,
    )
    done = 0
    for name in target:
        try:
            run_billing_pipeline_for_cycle(name, auto=1)
            done += 1
        except Exception:
            frappe.logger().warning(f"[Billing] auto run failed for {name}: {frappe.get_traceback()}")
    return {"success": True, "processed": done, "candidates": len(target)}


@frappe.whitelist()
def set_current_billing_cycle(company: str, billing_cycle: str) -> Dict[str, Any]:
    """Mark one cycle as current for a company; ensure uniqueness.
    If another current exists (and is different), raise error to signal data bug.
    """
    if not company or not billing_cycle:
        return {"success": False, "error": "company and billing_cycle are required"}
    try:
        cyc = frappe.get_doc("Account Billing Cycle Inst", billing_cycle)
        if getattr(cyc, "company", None) and str(cyc.company) != str(company):
            return {"success": False, "error": "cycle company mismatch"}
        # check existing current
        existing = frappe.db.get_value(
            "Account Billing Cycle Inst",
            {"company": company, "is_current": 1},
            ["name"],
            as_dict=True,
        )
        if existing and existing.get("name") != billing_cycle:
            frappe.throw(_(f"Another current cycle exists for {company}: {existing.get('name')}"))
        cyc.db_set("is_current", 1, update_modified=False)
        return {"success": True}
    except Exception as e:
        return {"success": False, "error": str(e)}


@frappe.whitelist()
def get_current_billing_cycle(company: str) -> Dict[str, Any]:
    """Return current cycle for company if any."""
    name = frappe.db.get_value("Account Billing Cycle Inst", {"company": company, "is_current": 1}, "name")
    if not name:
        return {"success": False}
    doc = frappe.get_doc("Account Billing Cycle Inst", name)
    return {"success": True, "name": doc.name, "cycle_code": getattr(doc, "cycle_code", None), "status": doc.status}


@frappe.whitelist()
def get_cycle_for_billing(company: str) -> Dict[str, Any]:
    """Prefer current(is_current=1). If none, fallback to latest Open by period_end desc."""
    cur = frappe.db.get_value("Account Billing Cycle Inst", {"company": company, "is_current": 1}, ["name", "cycle_code", "status"], as_dict=True)
    if cur:
        return {"success": True, **cur}
    # fallback latest Open
    rows = frappe.get_all(
        "Account Billing Cycle Inst",
        filters={"company": company, "status": "Open"},
        fields=["name", "cycle_code", "status", "period_end"],
        order_by="period_end desc",
        limit=1,
    )
    if rows:
        r = rows[0]
        return {"success": True, "name": r.get("name"), "cycle_code": r.get("cycle_code"), "status": r.get("status")}
    return {"success": False}


 


